package org.bouncycastle.openssl.test;

import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.Reader;
import java.math.BigInteger;
import java.security.GeneralSecurityException;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.Security;
import java.security.interfaces.RSAPublicKey;
import java.util.*;

import javax.crypto.Cipher;

import org.bouncycastle.asn1.x509.SubjectPublicKeyInfo;
import org.bouncycastle.openssl.PEMException;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;

public class Transaction {
	private static final int NUM_INPUTS_FIELD_SIZE = 2;
	private static final int INPUT_SPECIFIER_HEADER_SIZE= 164;
	private static final int INPUT_SPECIFIER_HEADER_PREV_TX_OUTPUT_IDX_OFFSET = 32;
	private static final int INPUT_SPECIFIER_HEADER_SIGNATURE_OFFSET = 34;		 
	private static final int INPUT_SPECIFIER_HEADER_PKEY_LENGTH_OFFSET = 162;

	private static final int INPUT_SPECIFIER_SIGNATURE_SIZE = 128;

	private static final int NUM_OUTPUTS_FIELD_SIZE = 2;
	private static final int OUTPUT_SPECIFIER_SIZE= 36;
	private static final int OUTPUT_SPECIFIER_HASHED_PKEY_OFFSET= 4;

	// the whole transaction byte data
	private byte [] data;

	// the hash of the transaction data (without signature fields)
	private byte [] dHashedElidedData;

	private byte [] dHashedData;

	private int numInputs;

	private long [] output;
	private long totalOutput;
	private List<byte[]> outputDHashedPKeys;
	private boolean [] outputSpent;
	private Long surplus;
	
	private byte [] redeemerPkey; // public key of the redeemer of the inputs
	private boolean isGenesisOrCoinBase;


	/**
	 * Constructs a new transaction object from the binary byte array, 
	 * starting from offSet
	 * @param bin
	 * @param offSet
	 * 
	 */
	public Transaction(byte[] bin, int offSet, boolean isGenesisOrCoinBase) {
		// initialize the arraylist used to store the transaction data without the signatures
		List<Byte> elidedData = new ArrayList<Byte>();

		// read the number of inputs
		this.numInputs = Util.readIntLittleEndianFromTwoBytes(bin, offSet);
		// System.out.println("Number of Inputs: " + this.numInputs);
		
		// copy the num inputs field to elidedData
		for (int i = offSet; i < offSet + NUM_INPUTS_FIELD_SIZE; i++) {
			elidedData.add(bin[i]);
		}

		/***************** process the inputs *****************/ 
		int curInputSpecifierRegionOffset = offSet + 2;
		
		for (int i = 0; i < this.numInputs; i++) {
			// get the length of the public key
			int pKeyLength = Util.readIntLittleEndianFromTwoBytes(bin,
					curInputSpecifierRegionOffset + 
					INPUT_SPECIFIER_HEADER_PKEY_LENGTH_OFFSET);			

			
			// copy the input specifier (without signature) to elidedData
			int elidedInputLength = INPUT_SPECIFIER_HEADER_SIZE + pKeyLength - INPUT_SPECIFIER_SIGNATURE_SIZE;

			for (int j = curInputSpecifierRegionOffset;
					j < curInputSpecifierRegionOffset + elidedInputLength;
					j++) {
				if (j < curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_SIGNATURE_OFFSET)
					elidedData.add(bin[j]);
				else
					elidedData.add(bin[j + INPUT_SPECIFIER_SIGNATURE_SIZE]);
			}
			
			
			// increment the current offset
			curInputSpecifierRegionOffset += INPUT_SPECIFIER_HEADER_SIZE + pKeyLength;
		}

		
		/***************** process outputs*****************/		  
		int curOutputSpecifierRegionOffset = curInputSpecifierRegionOffset;		
		
		// read the number of outputs
		int outputs = Util.readIntLittleEndianFromTwoBytes(bin, curOutputSpecifierRegionOffset);
		curOutputSpecifierRegionOffset += 2;

		//System.out.println("Number of Outputs: " + outputs);

		this.output = new long[outputs];
		this.outputSpent = new boolean[outputs];
		this.outputDHashedPKeys = new ArrayList<byte[]>();

		for (int i = 0; i < outputs; i++) {
			// System.out.println("Output " + i + ":");

			// read the output value and store it
			output[i] = 
					Util.readLongLittleEndianFromFourBytes(bin,
							curOutputSpecifierRegionOffset);

			// accumulate the output to the accumulation variable
			totalOutput += output[i];
			if (totalOutput < 0) {
				throw new IllegalStateException("total output value is negative.");
			}

			// read the output destination and store it
			this.outputDHashedPKeys.add(
					Arrays.copyOfRange(bin,
							curOutputSpecifierRegionOffset + OUTPUT_SPECIFIER_HASHED_PKEY_OFFSET,
							curOutputSpecifierRegionOffset + OUTPUT_SPECIFIER_SIZE));

			curOutputSpecifierRegionOffset += OUTPUT_SPECIFIER_SIZE;
		}
		
		// copy all the output specifers into elidedData
		for (int i = curInputSpecifierRegionOffset; 
				i < curOutputSpecifierRegionOffset; i++) {
			elidedData.add(bin[i]);
		}

		// initialize the data array field
		this.data = Arrays.copyOfRange(bin, offSet, curOutputSpecifierRegionOffset);

		// hash the original data array
		this.dHashedData = Util.dHash(this.data);
		
		// hash the elided data array
		byte [] elidedArr = new byte[elidedData.size()];
		for (int i = 0; i < elidedData.size(); i++) {
			elidedArr[i] = elidedData.get(i);
		}
		this.dHashedElidedData = Util.dHash(elidedArr);
		
		// set to default
		this.isGenesisOrCoinBase = isGenesisOrCoinBase;
		
		this.surplus = null;

		this.redeemerPkey = null;
	}

	// returns 0 if data is empty or object not properly initialized
	public int getDataSize() {
		if (data != null)
			return this.data.length;
		else
			return 0;
	}
	
	public long getSurplus () {
		
		if (this.surplus == null) {	
			throw new IllegalStateException("Must call checkValid() before calling this method");
		} else {
			return this.surplus;
		}
	}

	// returns a new array containing the dhashed transaction
	public byte[] getDHashedData() {
		return Arrays.copyOf(this.dHashedData, this.dHashedData.length);
	}

	/**
	 * To be valid, a transaction must:
			A: have total output value not larger than its total input value
			B: have valid references: 
				(i) the previous outputs used as inputs must exist
				(ii) for each referenced output:
					(1) the public key supplied in the input specifier must hash to the value given in that output
					(2) the signature given in the input specifier must have been produced by the private key corresponding to that public key.
			C: no previous output can have been already used as an input by any other transaction
	 *
	 */
	public boolean checkValid() {

		/***************** iterate over the inputs *****************/ 
		int curInputSpecifierRegionOffset = NUM_INPUTS_FIELD_SIZE;
		long cumulativeInput = 0;
				
		//Util.printByteArrayAsHex("Verifying Transaction With Name", this.dHashedData);
		//System.out.println("Transaction has " + numInputs + " input specifiers");
		
		for (int i = 0; i < this.numInputs; i++) {
			// System.out.println("Checking Input " + i + ":");

			// get the dhashed name of the input transaction		  
			byte [] dHashedInputTxnName = Arrays.copyOfRange(this.data,
					curInputSpecifierRegionOffset,
					curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_PREV_TX_OUTPUT_IDX_OFFSET);

			// get the index of the output 
			int prevTxnOutputIdx =  
					Util.readIntLittleEndianFromTwoBytes(this.data,
							curInputSpecifierRegionOffset +
							INPUT_SPECIFIER_HEADER_PREV_TX_OUTPUT_IDX_OFFSET);

			// get the signature
			byte [] signature = Arrays.copyOfRange(this.data, 
					curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_SIGNATURE_OFFSET,
					curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_PKEY_LENGTH_OFFSET);

			// get the length of the public key
			int pKeyLength = Util.readIntLittleEndianFromTwoBytes(this.data,
					curInputSpecifierRegionOffset + 
					INPUT_SPECIFIER_HEADER_PKEY_LENGTH_OFFSET);

			// get the public key
			byte [] pKey = Arrays.copyOfRange(this.data, 
					curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_SIZE,
					curInputSpecifierRegionOffset + INPUT_SPECIFIER_HEADER_SIZE + pKeyLength);
			//Util.printByteArrayAsHex("\tpKey in transaction " + i, pKey);
			redeemerPkey = pKey;

			// check if a previous output used as input exist
			Transaction inputTxn = MainClass.getTransaction(dHashedInputTxnName);
			if (inputTxn == null) {
				return false;
			}

			// check if there is an output at specified index, and if that output has not already been spent
			long prevTxnOutputValue = inputTxn.getOutputValueAtIdx(prevTxnOutputIdx);			  
			if (prevTxnOutputValue == -1 ||
					inputTxn.outputSpent(prevTxnOutputIdx)) {
				return false;
			}

			// check if the specified dHashed pkey in the output matches the pKey shown in the input
			byte [] dHashedPKeyAtOutput = inputTxn.getDHashedOutputPkey(prevTxnOutputIdx);
			byte [] dHashedPKeyAtInput = Util.dHash(pKey);
			
			if (!Arrays.equals(dHashedPKeyAtOutput, dHashedPKeyAtInput)) {
				return false;
			}

			// decrypt the signature using the public key
			Object o;
			PEMParser pemRd = openPEMResource(pKey);

			try {
				if ((o = pemRd.readObject()) != null &&
						o instanceof SubjectPublicKeyInfo ) {
					JcaPEMKeyConverter myConverter = new JcaPEMKeyConverter();
					RSAPublicKey RSAPKey = (RSAPublicKey) myConverter.getPublicKey((SubjectPublicKeyInfo) o);				
					
					byte [] decryptedSignature = decryptWithPKey(RSAPKey, signature);
					
					// check if the decrypted signature agrees with the elided data
					if (!Arrays.equals(decryptedSignature, dHashedElidedData)) {
						return false;
					}
					
				} else {
					System.out.println("Error: Not an instance of SubjectPublicKeyInfo.");
					return false;
				}
			} catch (Exception e) {				
				System.out.println("Encounted exception during signature checking. Dropping transaction...");
				return false;
			}
			
			// add up the input value to the cumulation variable
			cumulativeInput += prevTxnOutputValue;
			
			// mark the corresponding output as spent
			inputTxn.setOutputAsSpent(prevTxnOutputIdx, true);

			curInputSpecifierRegionOffset += INPUT_SPECIFIER_HEADER_SIZE + pKeyLength;
		}
		
		// compute the surplus
		this.surplus = cumulativeInput - this.totalOutput;
		
		// if not a genesis or coinbase transaction,
		// check if the total input is greater than or equal to the total output
		boolean result = (isGenesisOrCoinBase || cumulativeInput >= this.totalOutput);

		
		return result;
	}
	
	// returns the value of the output of the transaction at index idx
	// or returns -1 if the index is negative or out of bounds of the output array
	// of this transaction
	public long getOutputValueAtIdx(int idx) {
		if (idx >= 0 && idx < output.length)
			return output[idx];
		else
			return -1;
	}

	public byte [] getDHashedOutputPkey (int idx) {
		return outputDHashedPKeys.get(idx);
	}

	public boolean outputSpent(int idx) {
		return outputSpent[idx];
	}
	
	// set the output at specified index as spent output
	public void setOutputAsSpent(int idx, boolean value) {		
		outputSpent[idx] = value;
	}
	
	public byte [] getData() {
		return Arrays.copyOf(this.data, this.data.length);
	}
	
	private static PEMParser openPEMResource(byte[] keyBase64) {
	      Reader fRd = new BufferedReader(new InputStreamReader(new ByteArrayInputStream(keyBase64)));
	      return new PEMParser(fRd);
	   }
	  
	  public static byte[] decryptWithPKey(PublicKey publicKey, byte [] plain) throws GeneralSecurityException {
		    Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider());
		    Cipher rsa = Cipher.getInstance("RSA/None/PKCS1Padding", "BC");
		    rsa.init(Cipher.DECRYPT_MODE, publicKey);
		    return rsa.doFinal(plain);
	  }
	  
	  public void setIsGenesisOrCoinbase(boolean value) {
		  this.isGenesisOrCoinBase = value;
	  }
	  
	  public boolean isGenesisOrCoinBase() {
		  return this.isGenesisOrCoinBase;
	  }
	  
	  public byte [] getRedeemerPKey() {
		  return Arrays.copyOf(this.redeemerPkey, this.redeemerPkey.length);
	  }
	  
	  public long getTotalOutput () {
		  return this.totalOutput;
	  }
	  
	  public int getNumOutputs() {
		  return this.output.length;
	  }
}
